Fix codegen cycles and nullable-flag input parser (2.2.1)#390
Merged
jeremydmiller merged 2 commits intoMay 29, 2026
Conversation
Three independent regressions surfaced when Wolverine 6.2.0 and Marten
9.3.0 (both depending on JasperFx 2.2.0) run against a host that
exercises the inline IEnumerable<T> codegen path. Together they prevent
host startup; each contributes one stack-overflow or compile failure.
1. InputParserGenerator emits a `Func<string, object?>` for any flag
type that falls through the primitive switch — notably nullable
primitives like `int?` and `DateTime?`. `GeneratedFlag<T>` takes
`Func<string, T>`, so the generated parser fails to compile with
CS1503. Mirror the cast wrapper that GetElementConverterExpression
already uses: `s => ({T})...FindConverter(typeof({T}))!(s)`. Existing
primitive fast paths are unchanged.
2. DependencyGatherer's two co-recursive yield iterators have no cycle
protection — Frame.Uses → Variables[v] → Variable.Creator →
Dependencies[creator] can loop, and even acyclic-but-deep graphs (the
Wolverine/Marten WolverineRuntime constructor service graph) blow
the stack at ~250 levels. Replace both iterators with a single
iterative BFS that walks the (Frame, Variable) closure once with
HashSet-tracked visited nodes. The original walk had a side effect
of populating `Variables` cache keys that MethodFrameArranger.
findInjectedFields reads via `Variables.Keys()`; preserve that by
`Fill`-ing each visited Variable as the BFS sees it, otherwise
generated handler classes lose their injected field declarations and
their method bodies reference identifiers that aren't declared
(CS0103).
3. EnumerableSingletons.KeyedMirror's factory was
`(sp, _) => sp.GetServices(elementType).ElementAt(ordinal)`. When a
container inlines the singleton element of a mixed-lifetime
IEnumerable<T> via QuickResolve (Lamar does this for any
InjectedServiceField targeting a Singleton), the factory re-enters
the same IEnumerable<T> resolution while one of its elements is
being built — infinite recursion that stack-overflows in ~750 nested
ListAssignmentFrame.WriteExpressions / QuickResolve frames before any
handler runs. Bind directly to the source ServiceDescriptor's
ImplementationInstance / ImplementationFactory /
ImplementationType+ActivatorUtilities so the lookup never round-trips
through GetServices. Updated ArrayPlan and
EnumerableSingletonRegistrationExtensions to pass the source
descriptor through.
Bumps JasperFxVersion 2.2.0 → 2.2.1 (Fix). JasperFx.RuntimeCompiler is
independently versioned and unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The KeyedMirror change in 0e68505 made the keyed mirror's factory bind directly to the source ServiceDescriptor's ImplementationInstance / ImplementationFactory / ImplementationType. That broke CodegenTests.Services.inline_enumerable_with_mixed_lifetimes (`two_singletons_among_mixed_are_each_shared`, `singleton_element_is_the_shared_container_singleton`): for sources registered as `AddSingleton<T, Impl>()` or `AddSingleton<T>(factory)`, the rebound factory built a fresh instance instead of returning the container's cached non-keyed singleton, so the keyed mirror no longer mirrored. The original `sp.GetServices(elementType).ElementAt(ordinal)` factory is necessary to preserve sharing — the container's cached singleton only flows through GetServices. Lamar's IEnumerable<T> codegen cycle (the original motivation for the rebind) is rooted in Lamar's `ServiceFamily` building one family per service type with keyed and non-keyed instances combined: when KeyedMirror's factory calls `sp.GetServices(T)`, Lamar's `ListInstance.Elements` includes the keyed mirror itself and Lamar re-inlines it via `InjectedServiceField.ToVariableExpression`'s QuickResolve. MS DI's `GetServices` excludes keyed registrations, so the cycle is Lamar-specific. The right fix is in Lamar (or a filter applied via JasperFx that Lamar consults), not in KeyedMirror — defer to a follow-up. This commit keeps the InputParserGenerator and DependencyGatherer fixes from 0e68505 and the 2.2.0 → 2.2.1 version bump. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two regressions surfaced in 2.2.0 when downstream consumers
(Wolverine 6.2.0, Marten 9.3.0 + a code-generating container that
inlines singletons) exercise the inline
IEnumerable<T>codegen path.InputParserGenerator — the fallback converter expression emits
FindConverter(typeof(T))!(Func<string, object?>) which can't bindto
GeneratedFlag<T>'sFunc<string, T>for nullable primitives likeint?andDateTime?, producing CS1503 in the generated parser.Wrap with a cast (
s => (T)…(s)), mirroringGetElementConverterExpression.DependencyGatherer — co-recursive yield iterators with no cycle
protection. Frame⇄Variable graphs in real handlers cycle (or just go
deep enough — ~250+ levels — to blow the stack). Rewrite both
walks as a single iterative BFS with HashSet-tracked visited nodes.
Preserve the original walk's side effect of populating the
Variablescache so
MethodFrameArranger.findInjectedFields(which readsVariables.Keys()) still sees every injected field — otherwise thegenerated handler class loses its
_fielddeclarations and themethod body references undeclared identifiers (CS0103).
Bumps
JasperFxVersion2.2.0 → 2.2.1 (Fix).JasperFx.RuntimeCompileris independently versioned and untouched.
Originally included, now reverted
An earlier draft of this PR also rebound
EnumerableSingletons.KeyedMirror'sfactory to the source
ServiceDescriptordirectly (instead ofsp.GetServices(elementType).ElementAt(ordinal)) to break a stackoverflow seen with
AddJasperFxEnumerableSingletonSupport()against aLamar container. That change broke
inline_enumerable_with_mixed_lifetimes.{two_singletons_among_mixed_are_each_shared, singleton_element_is_the_shared_container_singleton}in CI: for sourcesregistered as
AddSingleton<T, Impl>()/AddSingleton<T>(factory), therebound factory built a fresh instance instead of returning the cached
non-keyed singleton, defeating the whole "mirror" contract.
The cycle is rooted in Lamar's
ServiceFamilycombining keyed andnon-keyed instances into one family per service type, so when
KeyedMirror's factory calls
sp.GetServices(T), Lamar'sListInstance.Elementsincludes the keyed mirror itself and Lamarre-inlines it via
InjectedServiceField.ToVariableExpression'sQuickResolve. MS DI'sGetServicesexcludes keyed registrations, sothis is Lamar-specific. Will follow up separately.
Test plan
CodegenTests.Services.inline_enumerable_with_mixed_lifetimes.*— 13/13 pass locally.CodegenTestsfull suite — 397/397 pass locally (net9 + net10).CommandLineTestsfull suite — 285/285 pass locally (net9 + net10).(
JasperFx,JasperFx.SourceGenerator,JasperFx.Events,JasperFx.Events.SourceGenerator).NetCoreInputsubclass withint?/DateTime?flagproperties compiles via the input-parser generator.
WolverineRuntimectorpreviously stack-overflowed in
DependencyGatherer.findDependenciesstarts cleanly and runs a handler.
🤖 Generated with Claude Code